🎖️GitЯра🎖️
Commit cd98090efada1f4670ba84c4fa9cf3b6a3dda456
Parents : 9b5e3bb
Author : Jeremiah K <17190268+jeremiah-k@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-06T12:08:25-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-06T17:08:25Z
fix(settings): Generate fresh PSK for named manual channels (#6076)
Changes
4 files changed, 283 insertions(+), 20 deletions(-)
Diff
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt
index efdc699b1a..d1b7970c56 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt
@@ -40,6 +40,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -70,6 +71,7 @@ import org.meshtastic.feature.settings.radio.channel.component.ChannelCard
import org.meshtastic.feature.settings.radio.channel.component.ChannelConfigHeader
import org.meshtastic.feature.settings.radio.channel.component.ChannelLegend
import org.meshtastic.feature.settings.radio.channel.component.ChannelLegendDialog
+import org.meshtastic.feature.settings.radio.channel.component.ChannelPskEditState
import org.meshtastic.feature.settings.radio.channel.component.EditChannelDialog
import org.meshtastic.feature.settings.radio.component.LoadingOverlay
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
@@ -122,12 +124,17 @@ private fun ChannelConfigScreen(
rememberSaveable(saver = listSaver(save = { it.toList() }, restore = { it.toMutableStateList() })) {
settingsList.toMutableStateList()
}
+ val pskEditStatesInput =
+ rememberSaveable(saver = channelPskEditStatesSaver) {
+ List(settingsListInput.size) { ChannelPskEditState() }.toMutableStateList()
+ }
val listState = rememberLazyListState()
val dragDropState =
rememberDragDropState(listState) { fromIndex, toIndex ->
if (toIndex in settingsListInput.indices && fromIndex in settingsListInput.indices) {
- settingsListInput.apply { add(toIndex, removeAt(fromIndex)) }
+ settingsListInput.move(fromIndex, toIndex)
+ pskEditStatesInput.move(fromIndex, toIndex)
}
}
@@ -143,11 +150,14 @@ private fun ChannelConfigScreen(
EditChannelDialog(
channelSettings = settingsListInput.getOrNull(index) ?: ChannelSettings(),
modemPresetName = modemPresetName,
- onAddClick = {
+ initialPskEditState = pskEditStatesInput.getOrElse(index) { ChannelPskEditState() },
+ onAddClick = { settings, pskEditState ->
if (settingsListInput.size > index) {
- settingsListInput[index] = it
+ settingsListInput[index] = settings
+ pskEditStatesInput[index] = pskEditState
} else {
- settingsListInput.add(it)
+ settingsListInput.add(settings)
+ pskEditStatesInput.add(pskEditState)
}
showEditChannelDialog = null
},
@@ -176,7 +186,8 @@ private fun ChannelConfigScreen(
FloatingActionButton(
onClick = {
if (maxChannels > settingsListInput.size) {
- settingsListInput.add(ChannelSettings(psk = Channel.default.settings.psk))
+ settingsListInput.add(Channel.default.settings)
+ pskEditStatesInput.add(ChannelPskEditState(canGeneratePskForName = true))
showEditChannelDialog = settingsListInput.lastIndex
}
},
@@ -231,7 +242,10 @@ private fun ChannelConfigScreen(
channelSettings = channel,
loraConfig = loraConfig,
onEditClick = { showEditChannelDialog = index },
- onDeleteClick = { settingsListInput.removeAt(index) },
+ onDeleteClick = {
+ settingsListInput.removeAt(index)
+ pskEditStatesInput.removeAt(index)
+ },
sharesLocation = locationChannel == index,
)
}
@@ -242,13 +256,16 @@ private fun ChannelConfigScreen(
negativeText = stringResource(Res.string.cancel),
onNegativeClicked = {
focusManager.clearFocus()
- settingsListInput.clear()
- settingsListInput.addAll(settingsList)
+ settingsListInput.replaceWith(settingsList)
+ pskEditStatesInput.replaceAll(settingsList.size, ChannelPskEditState())
},
positiveText = stringResource(Res.string.send),
onPositiveClicked = {
focusManager.clearFocus()
- onPositiveClicked(settingsListInput)
+ val committedSettings = settingsListInput.toList()
+ onPositiveClicked(committedSettings)
+ settingsListInput.replaceWith(committedSettings)
+ pskEditStatesInput.replaceAll(committedSettings.size, ChannelPskEditState())
},
)
}
@@ -273,6 +290,44 @@ private fun ChannelConfigScreen(
}
}
+private val channelPskEditStatesSaver =
+ listSaver<SnapshotStateList<ChannelPskEditState>, Int>(
+ save = { states -> states.map { it.toSaveableFlags() } },
+ restore = { flags -> flags.map { it.toChannelPskEditState() }.toMutableStateList() },
+ )
+
+internal fun <T> MutableList<T>.move(fromIndex: Int, toIndex: Int) {
+ add(toIndex, removeAt(fromIndex))
+}
+
+internal fun <T> MutableList<T>.replaceWith(values: List<T>) {
+ clear()
+ addAll(values)
+}
+
+internal fun MutableList<ChannelPskEditState>.replaceAll(size: Int, value: ChannelPskEditState) {
+ clear()
+ repeat(size) { add(value) }
+}
+
+internal fun ChannelPskEditState.toSaveableFlags(): Int {
+ var flags = 0
+ if (canGeneratePskForName) flags = flags or CAN_GENERATE_PSK_FOR_NAME_FLAG
+ if (generatedPskForName) flags = flags or GENERATED_PSK_FOR_NAME_FLAG
+ if (pskExplicitlyEdited) flags = flags or PSK_EXPLICITLY_EDITED_FLAG
+ return flags
+}
+
+internal fun Int.toChannelPskEditState(): ChannelPskEditState = ChannelPskEditState(
+ canGeneratePskForName = this and CAN_GENERATE_PSK_FOR_NAME_FLAG != 0,
+ generatedPskForName = this and GENERATED_PSK_FOR_NAME_FLAG != 0,
+ pskExplicitlyEdited = this and PSK_EXPLICITLY_EDITED_FLAG != 0,
+)
+
+private const val CAN_GENERATE_PSK_FOR_NAME_FLAG = 1
+private const val GENERATED_PSK_FOR_NAME_FLAG = 1 shl 1
+private const val PSK_EXPLICITLY_EDITED_FLAG = 1 shl 2
+
/**
* Determines what [Channel] if any is enabled to conduct automatic location sharing.
*
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt
index cb2176c033..ecd3901fb8 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt
@@ -53,21 +53,23 @@ import org.meshtastic.proto.ModuleSettings
@Composable
fun EditChannelDialog(
channelSettings: ChannelSettings,
- onAddClick: (ChannelSettings) -> Unit,
+ onAddClick: (ChannelSettings, ChannelPskEditState) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
modemPresetName: String = stringResource(Res.string.default_),
+ initialPskEditState: ChannelPskEditState = ChannelPskEditState(),
) {
val focusManager = LocalFocusManager.current
var isFocused by remember { mutableStateOf(false) }
var channelInput by remember(channelSettings) { mutableStateOf(channelSettings) }
+ var pskEditState by remember(channelSettings, initialPskEditState) { mutableStateOf(initialPskEditState) }
MeshtasticDialog(
onDismiss = onDismissRequest,
dismissText = stringResource(Res.string.cancel),
confirmText = stringResource(Res.string.save),
- onConfirm = { onAddClick(channelInput) },
+ onConfirm = { onAddClick(channelInput, pskEditState) },
modifier = modifier,
title = "", // Title is handled internally by specific items if needed, or we could add one
text = {
@@ -87,14 +89,9 @@ fun EditChannelDialog(
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
- val defaultPsk = Channel.default.settings.psk
- val newPsk =
- if (channelInput.psk == defaultPsk) {
- Channel.getRandomKey()
- } else {
- channelInput.psk
- }
- channelInput = channelInput.copy(name = it.trim(), psk = newPsk)
+ val update = channelInput.applyChannelNameEdit(it, pskEditState)
+ channelInput = update.settings
+ pskEditState = update.pskEditState
},
onFocusChanged = { isFocused = it.isFocused },
)
@@ -107,10 +104,14 @@ fun EditChannelDialog(
onValueChange = {
val fullPsk = Channel(ChannelSettings(psk = it)).psk
if (fullPsk.size in setOf(0, 16, 32)) {
+ pskEditState = pskEditState.copy(generatedPskForName = false, pskExplicitlyEdited = true)
channelInput = channelInput.copy(psk = it)
}
},
- onGenerateKey = { channelInput = channelInput.copy(psk = Channel.getRandomKey()) },
+ onGenerateKey = {
+ pskEditState = pskEditState.copy(generatedPskForName = false, pskExplicitlyEdited = true)
+ channelInput = channelInput.copy(psk = Channel.getRandomKey())
+ },
)
SwitchPreference(
@@ -142,3 +143,41 @@ fun EditChannelDialog(
},
)
}
+
+data class ChannelPskEditState(
+ val canGeneratePskForName: Boolean = false,
+ val generatedPskForName: Boolean = false,
+ val pskExplicitlyEdited: Boolean = false,
+)
+
+internal data class ChannelNameUpdate(val settings: ChannelSettings, val pskEditState: ChannelPskEditState)
+
+internal fun ChannelSettings.applyChannelNameEdit(
+ nameInput: String,
+ pskEditState: ChannelPskEditState,
+): ChannelNameUpdate {
+ val name = nameInput.trim()
+ val defaultPsk = Channel.default.settings.psk
+ val clearGeneratedPsk = name.isBlank() && pskEditState.generatedPskForName && !pskEditState.pskExplicitlyEdited
+ val generateNamedPsk =
+ name.isNotBlank() &&
+ psk == defaultPsk &&
+ pskEditState.canGeneratePskForName &&
+ !pskEditState.pskExplicitlyEdited
+ val nextPsk =
+ when {
+ clearGeneratedPsk -> defaultPsk
+ generateNamedPsk -> Channel.getRandomKey()
+ else -> psk
+ }
+ val nextGeneratedPskForName =
+ when {
+ clearGeneratedPsk -> false
+ generateNamedPsk -> true
+ else -> pskEditState.generatedPskForName
+ }
+ return ChannelNameUpdate(
+ settings = copy(name = name, psk = nextPsk),
+ pskEditState = pskEditState.copy(generatedPskForName = nextGeneratedPskForName),
+ )
+}
diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreenTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreenTest.kt
new file mode 100644
index 0000000000..c8194c6924
--- /dev/null
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreenTest.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.settings.radio.channel
+
+import org.meshtastic.feature.settings.radio.channel.component.ChannelPskEditState
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class ChannelConfigScreenTest {
+ @Test
+ fun `psk edit state round-trips through saveable flags for all boolean combinations`() {
+ for (canGenerate in listOf(false, true)) {
+ for (generated in listOf(false, true)) {
+ for (edited in listOf(false, true)) {
+ val original =
+ ChannelPskEditState(
+ canGeneratePskForName = canGenerate,
+ generatedPskForName = generated,
+ pskExplicitlyEdited = edited,
+ )
+ val restored = original.toSaveableFlags().toChannelPskEditState()
+ assertEquals(original, restored)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `move forward shifts element to higher index`() {
+ val list = mutableListOf("A", "B", "C", "D")
+ list.move(fromIndex = 1, toIndex = 3)
+ assertEquals(listOf("A", "C", "D", "B"), list)
+ }
+
+ @Test
+ fun `move backward shifts element to lower index`() {
+ val list = mutableListOf("A", "B", "C", "D")
+ list.move(fromIndex = 3, toIndex = 1)
+ assertEquals(listOf("A", "D", "B", "C"), list)
+ }
+
+ @Test
+ fun `replaceAll clears and refills with given size and value`() {
+ val list =
+ mutableListOf(
+ ChannelPskEditState(canGeneratePskForName = true),
+ ChannelPskEditState(generatedPskForName = true),
+ )
+ val fillValue = ChannelPskEditState(pskExplicitlyEdited = true)
+ list.replaceAll(size = 3, value = fillValue)
+ assertEquals(3, list.size)
+ assertTrue(list.all { it == fillValue })
+ }
+
+ @Test
+ fun `replaceWith clears and refills with committed values`() {
+ val list = mutableListOf("draft", "stale")
+
+ list.replaceWith(listOf("committed"))
+
+ assertEquals(listOf("committed"), list)
+ }
+
+ @Test
+ fun `replaceAll clears committed channel psk generation eligibility`() {
+ val list = mutableListOf(ChannelPskEditState(canGeneratePskForName = true, generatedPskForName = true))
+
+ list.replaceAll(size = 1, value = ChannelPskEditState())
+
+ assertEquals(listOf(ChannelPskEditState()), list)
+ }
+}
diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialogTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialogTest.kt
new file mode 100644
index 0000000000..3e16911d53
--- /dev/null
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialogTest.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.settings.radio.channel.component
+
+import org.meshtastic.core.model.Channel
+import org.meshtastic.proto.ChannelSettings
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotEquals
+import kotlin.test.assertTrue
+
+class EditChannelDialogTest {
+ @Test
+ fun `new named manual channel generates fresh psk`() {
+ val update =
+ Channel.default.settings.applyChannelNameEdit(
+ nameInput = "custom",
+ pskEditState = ChannelPskEditState(canGeneratePskForName = true),
+ )
+
+ assertEquals("custom", update.settings.name)
+ assertNotEquals(Channel.default.settings.psk, update.settings.psk)
+ assertTrue(update.pskEditState.generatedPskForName)
+ assertFalse(update.pskEditState.pskExplicitlyEdited)
+ }
+
+ @Test
+ fun `explicitly entered default marker is preserved when naming channel`() {
+ val update =
+ Channel.default.settings.applyChannelNameEdit(
+ nameInput = "custom",
+ pskEditState = ChannelPskEditState(canGeneratePskForName = true, pskExplicitlyEdited = true),
+ )
+
+ assertEquals("custom", update.settings.name)
+ assertEquals(Channel.default.settings.psk, update.settings.psk)
+ assertFalse(update.pskEditState.generatedPskForName)
+ }
+
+ @Test
+ fun `existing default psk channel is not rotated when renamed`() {
+ val update =
+ Channel.default.settings.applyChannelNameEdit(
+ nameInput = "custom",
+ pskEditState = ChannelPskEditState(canGeneratePskForName = false),
+ )
+
+ assertEquals("custom", update.settings.name)
+ assertEquals(Channel.default.settings.psk, update.settings.psk)
+ assertFalse(update.pskEditState.generatedPskForName)
+ }
+
+ @Test
+ fun `generated name psk is restored to default when name is cleared after reopen`() {
+ val generatedSettings = ChannelSettings(name = "custom", psk = Channel.getRandomKey())
+
+ val update =
+ generatedSettings.applyChannelNameEdit(
+ nameInput = "",
+ pskEditState = ChannelPskEditState(canGeneratePskForName = true, generatedPskForName = true),
+ )
+
+ assertEquals("", update.settings.name)
+ assertEquals(Channel.default.settings.psk, update.settings.psk)
+ assertFalse(update.pskEditState.generatedPskForName)
+ }
+}
Served by rngit 1.5.4 - Generated in 0.22s